page.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. 'use client';
  2. import { use, useCallback, useEffect, useState } from 'react';
  3. import Link from 'next/link';
  4. import { Copy, Trash2, AlertTriangle } from 'lucide-react';
  5. import { fetchApi, getDateTime } from '@/lib/utils/client';
  6. import Loading from '@/app/component/Loading';
  7. import Pagination from '@/app/component/Pagination';
  8. import NavTabs from '../../navTabs';
  9. import type { InventoryListResponse, InventoryRow, UseCouponResponse } from '@/types/store';
  10. const PER_PAGE = 30;
  11. const MASK_PLACEHOLDER = '* * * * * * * * * * * * * * * *';
  12. function formatExpiryDate(iso: string): string
  13. {
  14. const d = new Date(iso);
  15. const y = d.getFullYear();
  16. const m = String(d.getMonth() + 1).padStart(2, '0');
  17. const day = String(d.getDate()).padStart(2, '0');
  18. return `${y}-${m}-${day}`;
  19. }
  20. export default function InventoryDetailPage({ params }: { params: Promise<{ productID: string }> })
  21. {
  22. const { productID } = use(params);
  23. const productIdNum = parseInt(productID, 10);
  24. const [items, setItems] = useState<InventoryRow[]>([]);
  25. const [total, setTotal] = useState(0);
  26. const [page, setPage] = useState(1);
  27. const [loading, setLoading] = useState(true);
  28. const [revealedCodes, setRevealedCodes] = useState<Record<number, string>>({});
  29. const [justRevealedID, setJustRevealedID] = useState<number|null>(null);
  30. const [copiedID, setCopiedID] = useState<number|null>(null);
  31. const load = useCallback(async () => {
  32. setLoading(true);
  33. const res = await fetchApi<InventoryListResponse>(
  34. `/api/store/inventory?productID=${productIdNum}&page=${page}&perPage=${PER_PAGE}`,
  35. { silent: true }
  36. );
  37. if (res.success && res.data) {
  38. setItems(res.data.list);
  39. setTotal(res.data.total);
  40. }
  41. else {
  42. setItems([]);
  43. setTotal(0);
  44. }
  45. setLoading(false);
  46. }, [productIdNum, page]);
  47. useEffect(() => {
  48. load();
  49. }, [load]);
  50. const handleUse = async (inv: InventoryRow) => {
  51. if (inv.isExpired) {
  52. return;
  53. }
  54. const ok = window.confirm('쿠폰을 확인하면 사용 처리되며 환불받을 수 없습니다.\n계속하시겠습니까?');
  55. if (!ok) {
  56. return;
  57. }
  58. const res = await fetchApi<UseCouponResponse>(`/api/store/inventory/${inv.id}/use`, {
  59. method: 'POST',
  60. silent: true
  61. });
  62. if (res.success && res.data) {
  63. setRevealedCodes(prev => ({ ...prev, [inv.id]: res.data!.code }));
  64. setJustRevealedID(inv.id);
  65. setItems(prev => prev.map(it =>
  66. it.id === inv.id
  67. ? { ...it, usedAt: res.data!.usedAt, code: res.data!.code }
  68. : it
  69. ));
  70. // 잠시 강조 후 해제
  71. window.setTimeout(() => {
  72. setJustRevealedID(curr => (curr === inv.id ? null : curr));
  73. }, 3000);
  74. return;
  75. }
  76. window.alert(res.message || '쿠폰 사용 처리에 실패했습니다.');
  77. };
  78. const handleDelete = async (inv: InventoryRow) => {
  79. const ok = window.confirm('보관함에서 삭제하시겠습니까?');
  80. if (!ok) {
  81. return;
  82. }
  83. const res = await fetchApi(`/api/store/inventory/${inv.id}`, {
  84. method: 'DELETE',
  85. silent: true
  86. });
  87. if (res.success) {
  88. setItems(prev => prev.filter(it => it.id !== inv.id));
  89. setTotal(prev => Math.max(0, prev - 1));
  90. }
  91. else {
  92. window.alert(res.message || '삭제에 실패했습니다.');
  93. }
  94. };
  95. const handleCopy = async (code: string, id: number) => {
  96. try {
  97. await navigator.clipboard.writeText(code);
  98. setCopiedID(id);
  99. window.setTimeout(() => {
  100. setCopiedID(curr => (curr === id ? null : curr));
  101. }, 1500);
  102. }
  103. catch {
  104. window.prompt('아래 코드를 복사해주세요:', code);
  105. }
  106. };
  107. const productName = items[0]?.productName ?? '';
  108. const productThumbnail = items[0]?.productThumbnail ?? null;
  109. const gameName = items[0]?.gameName ?? '';
  110. return (
  111. <>
  112. <NavTabs />
  113. <div className='max-w-3xl px-4 sm:px-6 pb-8'>
  114. <div className='mb-4'>
  115. <Link href='/inventory' className='text-sm text-blue-600 dark:text-blue-400 hover:underline'>
  116. < 보관함으로
  117. </Link>
  118. </div>
  119. {/* 빨간 경고 안내 — 사용 시 환불 불가 */}
  120. <div className='flex items-start gap-2 mb-4 px-3 py-2.5 border border-red-300 dark:border-red-900/60 bg-red-50 dark:bg-red-950/30 rounded'>
  121. <AlertTriangle className='size-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5' />
  122. <p className='text-xs sm:text-sm text-red-700 dark:text-red-300 leading-relaxed'>
  123. 쿠폰 코드를 확인하면 <strong>즉시 사용 처리</strong>되며 <strong>환불받을 수 없습니다.</strong> 신중하게 확인해주세요.
  124. </p>
  125. </div>
  126. {!loading && items.length > 0 && (
  127. <div className='flex items-center gap-3 mb-4'>
  128. {productThumbnail && (
  129. <div className='w-16 h-16 sm:w-20 sm:h-20 bg-neutral-100 dark:bg-neutral-800 rounded overflow-hidden flex-shrink-0'>
  130. {/* eslint-disable-next-line @next/next/no-img-element */}
  131. <img src={productThumbnail} alt={productName} className='w-full h-full object-contain' />
  132. </div>
  133. )}
  134. <div className='min-w-0'>
  135. <div className='text-xs text-purple-600 dark:text-purple-400 truncate'>{gameName}</div>
  136. <h1 className='text-lg sm:text-xl font-bold truncate' title={productName}>{productName}</h1>
  137. <div className='text-xs text-neutral-500'>총 {total.toLocaleString()}장</div>
  138. </div>
  139. </div>
  140. )}
  141. {loading ? (
  142. <Loading />
  143. ) : items.length === 0 ? (
  144. <div className='text-center py-20 text-neutral-500'>보관함에 이 상품의 쿠폰이 없습니다.</div>
  145. ) : (
  146. <>
  147. <ul className='border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 divide-y divide-neutral-200 dark:divide-neutral-800'>
  148. {items.map((it) => {
  149. const reveal = revealedCodes[it.id] ?? it.code;
  150. const expired = it.isExpired;
  151. const justReveal = justRevealedID === it.id;
  152. const copied = copiedID === it.id;
  153. return (
  154. <li
  155. key={it.id}
  156. className='p-3 sm:p-4 flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4'
  157. >
  158. {/* 코드 영역 */}
  159. <div className='flex-1 min-w-0'>
  160. <div className='flex items-center gap-2 flex-wrap mb-1'>
  161. {reveal ? (
  162. <>
  163. <input
  164. type='text'
  165. readOnly
  166. value={reveal}
  167. onFocus={(e) => e.currentTarget.select()}
  168. className={`font-mono w-full sm:max-w-xs border rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900 select-all transition-shadow ${justReveal ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-900' : 'border-neutral-300 dark:border-neutral-700'}`}
  169. aria-label='쿠폰 코드'
  170. />
  171. <button
  172. type='button'
  173. onClick={() => handleCopy(reveal, it.id)}
  174. className='inline-flex items-center justify-center p-2 rounded border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-300 shrink-0'
  175. title='클립보드에 복사'
  176. aria-label='쿠폰 코드 복사'
  177. >
  178. <Copy className='size-4' />
  179. </button>
  180. {copied && (
  181. <span className='text-xs text-green-600 dark:text-green-400 whitespace-nowrap'>복사됨!</span>
  182. )}
  183. {justReveal && !copied && (
  184. <span className='text-xs text-blue-600 dark:text-blue-400 whitespace-nowrap'>방금 사용됨</span>
  185. )}
  186. </>
  187. ) : expired ? (
  188. <>
  189. <input
  190. type='text'
  191. readOnly
  192. disabled
  193. value={MASK_PLACEHOLDER}
  194. className='font-mono w-full sm:max-w-xs border border-neutral-200 dark:border-neutral-800 rounded px-3 py-2 text-sm bg-neutral-100 dark:bg-neutral-800/40 text-neutral-400 cursor-not-allowed tracking-wider'
  195. aria-label='만료된 쿠폰 코드'
  196. />
  197. <span className='text-xs text-red-600 dark:text-red-400 whitespace-nowrap'>만료</span>
  198. </>
  199. ) : (
  200. <>
  201. <input
  202. type='text'
  203. readOnly
  204. value={MASK_PLACEHOLDER}
  205. onClick={() => handleUse(it)}
  206. className='font-mono w-full sm:max-w-xs border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-neutral-50 dark:bg-neutral-800 text-neutral-400 tracking-wider cursor-pointer hover:border-blue-500 hover:text-neutral-500'
  207. aria-label='쿠폰 코드 (확인 버튼을 눌러주세요)'
  208. />
  209. <button
  210. type='button'
  211. onClick={() => handleUse(it)}
  212. className='px-3 py-2 rounded text-sm font-semibold bg-blue-600 text-white hover:bg-blue-700 whitespace-nowrap shrink-0'
  213. >
  214. 확인
  215. </button>
  216. </>
  217. )}
  218. </div>
  219. {it.expiresAt && !expired && (
  220. <p className='text-xs text-orange-600 dark:text-orange-400'>
  221. ~ {formatExpiryDate(it.expiresAt)} 까지 사용 가능
  222. </p>
  223. )}
  224. </div>
  225. {/* 메타 정보 */}
  226. <div className='flex sm:flex-col gap-2 sm:gap-0 sm:items-end text-xs text-neutral-500 dark:text-neutral-400 whitespace-nowrap'>
  227. <span>
  228. <span className='sm:hidden'>구매: </span>
  229. <span className='hidden sm:inline'>구매 </span>
  230. {getDateTime(it.acquiredAt)}
  231. </span>
  232. <span>
  233. <span className='sm:hidden'>· 사용: </span>
  234. <span className='hidden sm:inline'>사용 </span>
  235. {it.usedAt ? getDateTime(it.usedAt) : '-'}
  236. </span>
  237. </div>
  238. {/* 삭제 */}
  239. <button
  240. type='button'
  241. onClick={() => handleDelete(it)}
  242. className='self-end sm:self-center inline-flex items-center justify-center p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-500 hover:text-red-600 shrink-0'
  243. title='보관함에서 삭제'
  244. aria-label='삭제'
  245. >
  246. <Trash2 className='size-4' />
  247. </button>
  248. </li>
  249. );
  250. })}
  251. </ul>
  252. <div className='mt-4'>
  253. <Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
  254. </div>
  255. </>
  256. )}
  257. </div>
  258. </>
  259. );
  260. }